Visitor Pattern Real-World Example in C#: Complete Implementation
Most visitor pattern tutorials show a shape hierarchy with an AreaCalculator visitor, and that's where the explanation stops. That won't help you the next time you need to export a document to HTML, Markdown, and plain text without modifying a single element class. This article builds a complete visitor pattern real-world example in C# -- a document export system where multiple output formats are implemented as visitors that traverse a tree of document elements.
By the end, you'll have compilable classes covering the full evolution: the element hierarchy, the visitor interface, three concrete export visitors, unit tests for each, and a demonstration of how to add a brand-new export format without touching any existing element class. If you've worked with composite structures for tree-based hierarchies, you'll see how the visitor pattern complements that approach by separating traversal operations from the data structure itself.
The Problem: Hard-Coded Export Logic Inside Element Classes
Imagine you're building a content management system. Documents contain headings, paragraphs, images, tables, and code blocks. You need to export those documents in multiple formats -- HTML for web rendering, Markdown for developer documentation, and plain text for email previews.
Without the visitor pattern, the naive approach puts export logic directly into each element:
public sealed class Heading
{
public int Level { get; }
public string Text { get; }
public Heading(int level, string text)
{
Level = level;
Text = text;
}
public string ToHtml()
=> $"<h{Level}>{Text}</h{Level}>";
public string ToMarkdown()
=> $"{new string('#', Level)} {Text}";
public string ToPlainText()
=> Text.ToUpperInvariant();
}
This works for one element and two formats, but the problems multiply fast. Every new export format requires modifying every element class. Every new element type requires updating every export method across the codebase. The element classes accumulate rendering responsibilities that have nothing to do with their core purpose -- representing document structure.
The visitor pattern eliminates this by extracting each export format into its own class. Element classes accept visitors without knowing what those visitors do. New formats mean new visitor classes -- not changes to existing elements.
Defining the Document Element Hierarchy
Before building visitors, we need the elements they'll operate on. Each document element implements an IDocumentElement interface with a single Accept method. This is the double-dispatch mechanism at the heart of the visitor pattern in C#:
public interface IDocumentElement
{
void Accept(IDocumentVisitor visitor);
}
Now let's define five concrete element types. Each one stores its own data and delegates operations to the visitor:
public sealed class Heading : IDocumentElement
{
public int Level { get; }
public string Text { get; }
public Heading(int level, string text)
{
Level = level;
Text = text;
}
public void Accept(IDocumentVisitor visitor)
=> visitor.Visit(this);
}
public sealed class Paragraph : IDocumentElement
{
public string Text { get; }
public Paragraph(string text)
{
Text = text;
}
public void Accept(IDocumentVisitor visitor)
=> visitor.Visit(this);
}
public sealed class Image : IDocumentElement
{
public string Url { get; }
public string AltText { get; }
public Image(string url, string altText)
{
Url = url;
AltText = altText;
}
public void Accept(IDocumentVisitor visitor)
=> visitor.Visit(this);
}
public sealed class Table : IDocumentElement
{
public IReadOnlyList<string> Headers { get; }
public IReadOnlyList<IReadOnlyList<string>> Rows { get; }
public Table(
IReadOnlyList<string> headers,
IReadOnlyList<IReadOnlyList<string>> rows)
{
Headers = headers;
Rows = rows;
}
public void Accept(IDocumentVisitor visitor)
=> visitor.Visit(this);
}
public sealed class CodeBlock : IDocumentElement
{
public string Language { get; }
public string Code { get; }
public CodeBlock(string language, string code)
{
Language = language;
Code = code;
}
public void Accept(IDocumentVisitor visitor)
=> visitor.Visit(this);
}
Each element class is sealed and immutable. The Accept method is always one line -- it calls the visitor's overloaded Visit method, passing this. This is what enables the visitor pattern's double dispatch: the runtime type of both the element and the visitor determine which method runs. This separation of concerns echoes inversion of control principles where objects delegate behavior rather than owning it.
Designing the Visitor Interface
The visitor interface declares one Visit overload per element type. This is the contract that every export format must fulfill:
public interface IDocumentVisitor
{
void Visit(Heading heading);
void Visit(Paragraph paragraph);
void Visit(Image image);
void Visit(Table table);
void Visit(CodeBlock codeBlock);
}
Each method receives a strongly typed element, giving the visitor full access to that element's properties without casting. When a new element type is added to the hierarchy, the compiler forces every visitor to handle it -- a safety net that prevents silent failures.
Building the HTML Export Visitor
The first visitor converts document elements into HTML. It accumulates output in a StringBuilder:
using System.Text;
public sealed class HtmlExportVisitor : IDocumentVisitor
{
private readonly StringBuilder _builder = new();
public string GetResult() => _builder.ToString();
public void Visit(Heading heading)
{
_builder.AppendLine(
$"<h{heading.Level}>{heading.Text}</h{heading.Level}>");
}
public void Visit(Paragraph paragraph)
{
_builder.AppendLine($"<p>{paragraph.Text}</p>");
}
public void Visit(Image image)
{
_builder.AppendLine(
$"<img src="{image.Url}" alt="{image.AltText}" />");
}
public void Visit(Table table)
{
_builder.AppendLine("<table>");
_builder.AppendLine("<thead><tr>");
foreach (var header in table.Headers)
{
_builder.AppendLine($"<th>{header}</th>");
}
_builder.AppendLine("</tr></thead>");
_builder.AppendLine("<tbody>");
foreach (var row in table.Rows)
{
_builder.AppendLine("<tr>");
foreach (var cell in row)
{
_builder.AppendLine($"<td>{cell}</td>");
}
_builder.AppendLine("</tr>");
}
_builder.AppendLine("</tbody>");
_builder.AppendLine("</table>");
}
public void Visit(CodeBlock codeBlock)
{
_builder.AppendLine(
$"<pre><code class="language-{codeBlock.Language}">" +
$"{codeBlock.Code}</code></pre>");
}
}
The visitor owns the rendering logic completely. The element classes know nothing about HTML. If HTML formatting requirements change, only this class is modified. The same element objects can be visited by completely different visitors without any changes.
Building the Markdown Export Visitor
The Markdown visitor follows the same structure but produces different output:
using System.Text;
public sealed class MarkdownExportVisitor : IDocumentVisitor
{
private readonly StringBuilder _builder = new();
public string GetResult() => _builder.ToString();
public void Visit(Heading heading)
{
var prefix = new string('#', heading.Level);
_builder.AppendLine($"{prefix} {heading.Text}");
_builder.AppendLine();
}
public void Visit(Paragraph paragraph)
{
_builder.AppendLine(paragraph.Text);
_builder.AppendLine();
}
public void Visit(Image image)
{
_builder.AppendLine(
$"");
_builder.AppendLine();
}
public void Visit(Table table)
{
_builder.AppendLine(
"| " + string.Join(" | ", table.Headers) + " |");
_builder.AppendLine(
"| " + string.Join(
" | ",
table.Headers.Select(_ => "---")) + " |");
foreach (var row in table.Rows)
{
_builder.AppendLine(
"| " + string.Join(" | ", row) + " |");
}
_builder.AppendLine();
}
public void Visit(CodeBlock codeBlock)
{
_builder.AppendLine(
$"```{codeBlock.Language}");
_builder.AppendLine(codeBlock.Code);
_builder.AppendLine("```");
_builder.AppendLine();
}
}
Notice how each visitor class is self-contained. The Markdown visitor doesn't know the HTML visitor exists, and neither knows about the elements' internal implementation details beyond their public properties. This is the visitor pattern's core strength in C# -- each new export format is a completely independent class.
Building the Plain Text Export Visitor
The third visitor strips all formatting for scenarios like email previews or accessibility readers:
using System.Text;
public sealed class PlainTextExportVisitor : IDocumentVisitor
{
private readonly StringBuilder _builder = new();
public string GetResult() => _builder.ToString();
public void Visit(Heading heading)
{
_builder.AppendLine(
heading.Text.ToUpperInvariant());
_builder.AppendLine();
}
public void Visit(Paragraph paragraph)
{
_builder.AppendLine(paragraph.Text);
_builder.AppendLine();
}
public void Visit(Image image)
{
_builder.AppendLine(
$"[Image: {image.AltText}]");
_builder.AppendLine();
}
public void Visit(Table table)
{
var columnWidths = table.Headers
.Select((h, i) => Math.Max(
h.Length,
table.Rows.Max(r => r[i].Length)))
.ToList();
_builder.AppendLine(string.Join(
" ",
table.Headers.Select(
(h, i) => h.PadRight(columnWidths[i]))));
_builder.AppendLine(string.Join(
" ",
columnWidths.Select(
w => new string('-', w))));
foreach (var row in table.Rows)
{
_builder.AppendLine(string.Join(
" ",
row.Select(
(c, i) => c.PadRight(columnWidths[i]))));
}
_builder.AppendLine();
}
public void Visit(CodeBlock codeBlock)
{
_builder.AppendLine(codeBlock.Code);
_builder.AppendLine();
}
}
The plain text visitor handles each element differently than the other two. Headings become uppercase text. Images become descriptive placeholders. Tables use column-aligned formatting with padding. All three visitors work on the exact same element objects -- the visitor pattern in C# lets you add unlimited operations without ever modifying the elements.
Orchestrating the Export with a Document Class
With elements and visitors defined, we need a Document class that holds a collection of elements and runs a visitor across all of them:
public sealed class Document
{
private readonly List<IDocumentElement> _elements = new();
public IReadOnlyList<IDocumentElement> Elements
=> _elements.AsReadOnly();
public void Add(IDocumentElement element)
{
_elements.Add(element);
}
public void Accept(IDocumentVisitor visitor)
{
foreach (var element in _elements)
{
element.Accept(visitor);
}
}
}
The Document class acts as a composite container. Its Accept method iterates through all child elements and delegates to each one, which in turn calls the visitor. This composability is similar to how the composite design pattern structures tree-like hierarchies. Here's how you build a document and export it:
var document = new Document();
document.Add(new Heading(1, "Getting Started"));
document.Add(new Paragraph(
"This guide covers the basics of the system."));
document.Add(new Image(
"https://example.com/diagram.png",
"Architecture diagram"));
document.Add(new Table(
new[] { "Feature", "Status" },
new[]
{
new[] { "Authentication", "Complete" },
new[] { "Authorization", "In Progress" },
}));
document.Add(new CodeBlock(
"csharp",
"Console.WriteLine("Hello, World!");"));
var htmlVisitor = new HtmlExportVisitor();
document.Accept(htmlVisitor);
string html = htmlVisitor.GetResult();
var markdownVisitor = new MarkdownExportVisitor();
document.Accept(markdownVisitor);
string markdown = markdownVisitor.GetResult();
The document doesn't know or care which visitor it receives. You can pass any implementation of IDocumentVisitor, and the double-dispatch mechanism routes each element to the correct Visit overload. This makes the system open for extension -- new export formats, new analysis passes, new transformations -- all without modifying existing code.
Unit Testing the Visitor Pattern Implementation
Each visitor is independently testable. Because visitors are self-contained and elements are immutable, tests are straightforward -- create elements, run the visitor, and assert the output. Here are tests covering all three export visitors:
using Xunit;
public sealed class HtmlExportVisitorTests
{
[Fact]
public void Visit_Heading_ProducesCorrectHtmlTag()
{
var visitor = new HtmlExportVisitor();
var heading = new Heading(2, "Introduction");
heading.Accept(visitor);
Assert.Contains(
"<h2>Introduction</h2>",
visitor.GetResult());
}
[Fact]
public void Visit_Paragraph_WrapsInPTag()
{
var visitor = new HtmlExportVisitor();
var paragraph = new Paragraph("Some text here.");
paragraph.Accept(visitor);
Assert.Contains(
"<p>Some text here.</p>",
visitor.GetResult());
}
[Fact]
public void Visit_Image_ProducesImgTag()
{
var visitor = new HtmlExportVisitor();
var image = new Image(
"https://example.com/photo.png",
"A photo");
image.Accept(visitor);
var result = visitor.GetResult();
Assert.Contains("src="https://example.com/photo.png"", result);
Assert.Contains("alt="A photo"", result);
}
[Fact]
public void Visit_Table_ProducesTheadAndTbody()
{
var visitor = new HtmlExportVisitor();
var table = new Table(
new[] { "Name", "Age" },
new[] { new[] { "Alice", "30" } });
table.Accept(visitor);
var result = visitor.GetResult();
Assert.Contains("<thead>", result);
Assert.Contains("<th>Name</th>", result);
Assert.Contains("<td>Alice</td>", result);
}
[Fact]
public void Visit_CodeBlock_ProducesPreCodeTags()
{
var visitor = new HtmlExportVisitor();
var codeBlock = new CodeBlock(
"csharp",
"var x = 42;");
codeBlock.Accept(visitor);
var result = visitor.GetResult();
Assert.Contains("language-csharp", result);
Assert.Contains("var x = 42;", result);
}
}
public sealed class MarkdownExportVisitorTests
{
[Theory]
[InlineData(1, "# Title")]
[InlineData(2, "## Title")]
[InlineData(3, "### Title")]
public void Visit_Heading_ProducesCorrectPrefix(
int level,
string expected)
{
var visitor = new MarkdownExportVisitor();
var heading = new Heading(level, "Title");
heading.Accept(visitor);
Assert.Contains(expected, visitor.GetResult());
}
[Fact]
public void Visit_Image_ProducesMarkdownImageSyntax()
{
var visitor = new MarkdownExportVisitor();
var image = new Image(
"https://images.test/img.png",
"Alt text");
image.Accept(visitor);
var result = visitor.GetResult();
Assert.Contains("![Alt text]", result);
Assert.Contains("images.test/img.png", result);
}
[Fact]
public void Visit_Table_ProducesMarkdownTable()
{
var visitor = new MarkdownExportVisitor();
var table = new Table(
new[] { "Col1", "Col2" },
new[] { new[] { "A", "B" } });
table.Accept(visitor);
var result = visitor.GetResult();
Assert.Contains("| Col1 | Col2 |", result);
Assert.Contains("| A | B |", result);
}
[Fact]
public void Visit_CodeBlock_ProducesFencedCodeBlock()
{
var visitor = new MarkdownExportVisitor();
var codeBlock = new CodeBlock(
"python",
"print('hello')");
codeBlock.Accept(visitor);
var result = visitor.GetResult();
Assert.Contains("```python", result);
Assert.Contains("print('hello')", result);
}
}
public sealed class PlainTextExportVisitorTests
{
[Fact]
public void Visit_Heading_ConvertsToUpperCase()
{
var visitor = new PlainTextExportVisitor();
var heading = new Heading(1, "Introduction");
heading.Accept(visitor);
Assert.Contains(
"INTRODUCTION",
visitor.GetResult());
}
[Fact]
public void Visit_Image_ProducesAltTextPlaceholder()
{
var visitor = new PlainTextExportVisitor();
var image = new Image(
"https://example.com/img.png",
"A diagram");
image.Accept(visitor);
Assert.Contains(
"[Image: A diagram]",
visitor.GetResult());
}
[Fact]
public void Visit_CodeBlock_OutputsRawCode()
{
var visitor = new PlainTextExportVisitor();
var codeBlock = new CodeBlock(
"csharp",
"var x = 1;");
codeBlock.Accept(visitor);
Assert.Contains(
"var x = 1;",
visitor.GetResult());
}
}
Testing a visitor pattern implementation is inherently clean because each visitor produces deterministic output from immutable input. You don't need mocking frameworks -- elements are real objects, and visitors operate on them directly. This aligns with the same testing simplicity you find when using the command pattern for undoable operations.
Adding a New Export Format Without Changing Elements
This is where the visitor pattern truly shines. Suppose you need to generate PDF metadata -- collecting title, image count, and word count for a PDF cover page. You add a new visitor class without modifying any existing element or visitor:
public sealed class PdfMetadataVisitor : IDocumentVisitor
{
public string? Title { get; private set; }
public int ImageCount { get; private set; }
public int WordCount { get; private set; }
public int TableCount { get; private set; }
public int CodeBlockCount { get; private set; }
public void Visit(Heading heading)
{
if (Title is null && heading.Level == 1)
{
Title = heading.Text;
}
WordCount += heading.Text
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Length;
}
public void Visit(Paragraph paragraph)
{
WordCount += paragraph.Text
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Length;
}
public void Visit(Image image)
{
ImageCount++;
}
public void Visit(Table table)
{
TableCount++;
foreach (var row in table.Rows)
{
foreach (var cell in row)
{
WordCount += cell
.Split(
' ',
StringSplitOptions.RemoveEmptyEntries)
.Length;
}
}
}
public void Visit(CodeBlock codeBlock)
{
CodeBlockCount++;
}
}
Here's a test confirming the new visitor works with existing elements:
public sealed class PdfMetadataVisitorTests
{
[Fact]
public void Visit_FullDocument_CollectsMetadata()
{
var document = new Document();
document.Add(new Heading(1, "My Document Title"));
document.Add(new Paragraph("This has five words here."));
document.Add(new Image(
"https://example.com/img.png",
"Photo"));
document.Add(new Image(
"https://example.com/img2.png",
"Diagram"));
document.Add(new CodeBlock("csharp", "var x = 1;"));
var visitor = new PdfMetadataVisitor();
document.Accept(visitor);
Assert.Equal("My Document Title", visitor.Title);
Assert.Equal(2, visitor.ImageCount);
Assert.Equal(8, visitor.WordCount);
Assert.Equal(1, visitor.CodeBlockCount);
}
}
Zero element classes were modified. Zero existing visitors were touched. The new PdfMetadataVisitor simply implements IDocumentVisitor and plugs into the same infrastructure. This is the Open/Closed Principle in practice -- the system is open for extension but closed for modification.
Wiring Visitors with Dependency Injection
In a production system, you'll want to resolve visitors from a DI container rather than instantiating them directly. A factory approach combined with a format enum keeps the registration clean:
public enum ExportFormat
{
Html,
Markdown,
PlainText,
}
public interface IExportVisitorFactory
{
IDocumentVisitor Create(ExportFormat format);
}
public sealed class ExportVisitorFactory
: IExportVisitorFactory
{
public IDocumentVisitor Create(ExportFormat format)
{
return format switch
{
ExportFormat.Html => new HtmlExportVisitor(),
ExportFormat.Markdown
=> new MarkdownExportVisitor(),
ExportFormat.PlainText
=> new PlainTextExportVisitor(),
_ => throw new ArgumentOutOfRangeException(
nameof(format),
$"Unsupported format: {format}"),
};
}
}
Register the factory as a singleton and visitors as transient:
using Microsoft.Extensions.DependencyInjection;
public static class DocumentExportRegistration
{
public static IServiceCollection AddDocumentExport(
this IServiceCollection services)
{
services.AddSingleton<IExportVisitorFactory,
ExportVisitorFactory>();
return services;
}
}
This registration pattern means consuming code depends on abstractions rather than concrete visitor types. Adding a new format means creating the visitor class, adding an enum value, and updating the factory switch -- a strategy-like approach you might recognize from the strategy design pattern.
Production Considerations for the Visitor Pattern in C#
The visitor pattern solves the document export problem cleanly, but production systems introduce additional concerns worth addressing.
Performance with large documents. Each visitor allocates a StringBuilder and iterates all elements sequentially. For documents with thousands of elements, consider using StringBuilder with a pre-allocated capacity based on element count. If visitors run concurrently on the same document, elements are immutable, so parallel execution is safe without locking.
Error handling within visitors. If a visitor's Visit method throws, the traversal stops mid-document. In production, you may want to wrap each Visit call in the Document.Accept loop with try-catch logic, collecting errors while continuing to process remaining elements. Alternatively, return a result object from each visitor that includes both output and any diagnostics.
Extensibility trade-offs. The visitor pattern makes adding new operations easy but makes adding new element types harder -- every existing visitor must be updated. If your element hierarchy changes frequently, the interpreter pattern or a different approach might suit you better. If your operations change frequently but elements are stable -- exactly the document export case -- the visitor pattern is the right tool.
Combining visitors with iteration. In scenarios where you need to iterate elements in a custom order, you can pair the visitor pattern with an iterator that controls traversal order. The visitor handles what happens at each element, while the iterator controls which elements are visited and in what sequence.
Avoiding visitor bloat. As formats multiply, the factory grows. You can mitigate this by scanning assemblies for IDocumentVisitor implementations and registering them by convention. This is especially helpful when visitors live in plugin assemblies loaded at runtime, similar to how the proxy pattern wraps access to remote or lazy-loaded components.
Frequently Asked Questions
What makes the visitor pattern different from polymorphism?
Standard polymorphism dispatches on the type of the object you're calling the method on. The visitor pattern uses double dispatch -- the method that runs depends on both the element type and the visitor type. This lets you add new operations (visitors) without modifying the element classes, which regular polymorphism doesn't support.
When should I use the visitor pattern instead of adding methods to element classes?
Use the visitor pattern when you have a stable set of element types but need to add new operations frequently. If you only have one or two operations, putting them directly on the elements is simpler. Once you reach three or more operations that would need to exist on every element class, the visitor pattern pays for itself by keeping each operation in a single, cohesive class.
Can the visitor pattern work with sealed class hierarchies in C#?
Yes, and sealed classes are actually ideal. Since visitor pattern implementations require a known, finite set of element types, sealing the classes communicates that the hierarchy is closed. Each visitor must handle every element type, and the compiler enforces this through the interface contract.
How do I handle a new element type without breaking existing visitors?
Adding a new element type requires adding a new Visit overload to IDocumentVisitor, which forces all existing visitors to implement it. You can ease this by providing a base class with default implementations, but be cautious -- silent default behavior can mask bugs. The compile-time break is a feature, not a flaw.
Is the visitor pattern compatible with async operations in C#?
Yes. Define an IAsyncDocumentVisitor with Task Visit(...) methods and change the Accept method to return Task. This is useful when visitors need to perform I/O -- writing to streams, calling APIs, or querying databases during traversal.
How does the visitor pattern compare to pattern matching with switch expressions?
C# pattern matching with switch expressions can achieve similar dispatch, but the logic ends up in a single method rather than distributed across visitor classes. For small element hierarchies and one or two operations, pattern matching is simpler. For many operations across many element types, the visitor pattern scales better because each operation is a separate class with its own state and tests.
Can I combine the visitor pattern with other behavioral patterns?
Absolutely. Visitors pair naturally with composite structures for tree traversal, with iterators for custom traversal orders, and with factories for visitor selection. In the document export system above, the ExportVisitorFactory uses a strategy-like selection mechanism to choose which visitor to create based on the requested format.
Wrapping Up This Visitor Pattern Real-World Example
This implementation shows the visitor pattern solving a real problem -- exporting documents to multiple formats without polluting element classes with rendering logic. We started with element classes that would accumulate a new method for every format. We ended with an architecture where each export format lives in its own visitor class, elements remain focused on data, and new formats are added by writing a new class that implements IDocumentVisitor.
The visitor pattern in C# shines in any system where a stable data structure needs to support multiple, evolving operations. Document exporters, AST analyzers, report generators, and serialization frameworks are all natural fits. Take this document export example, swap the element hierarchy for your own domain model, and you've got a production-ready visitor infrastructure that keeps your operations testable, your elements clean, and your architecture open to new visitors without touching existing code.

